import _ from 'lodash'
import d from 'd'
import memoizee from 'memoizee'
import memoizeeMethods from 'memoizee/methods.js'
import utils from '@serverlessinc/sf-core/src/utils.js'
import ServerlessError from '../../../../../serverless-error.js'
import resolveLambdaTarget from '../../../utils/resolve-lambda-target.js'

const { log } = utils

const allowedMethods = new Set([
  'ANY',
  'GET',
  'POST',
  'PUT',
  'PATCH',
  'OPTIONS',
  'HEAD',
  'DELETE',
])
const methodPattern = new RegExp(
  `^(?:\\*|${Array.from(allowedMethods).join('|')})$`,
  'i',
)
const methodPathPattern = new RegExp(
  `^(?:\\*|(${Array.from(allowedMethods).join('|')}) (\\/\\S*))$`,
  'i',
)

const resolveTargetConfig = memoizee(({ functionLogicalId, functionAlias }) => {
  const functionArnGetter = { 'Fn::GetAtt': [functionLogicalId, 'Arn'] }
  if (!functionAlias) return functionArnGetter
  return { 'Fn::Join': [':', [functionArnGetter, functionAlias.name]] }
})

const defaultCors = {
  allowedOrigins: new Set(['*']),
  allowedHeaders: new Set([
    'Content-Type',
    'X-Amz-Date',
    'Authorization',
    'X-Api-Key',
    'X-Amz-Security-Token',
    'X-Amz-User-Agent',
    'X-Amzn-Trace-Id',
  ]),
}

const toSet = (item) => new Set(Array.isArray(item) ? item : [item])

class HttpApiEvents {
  constructor(serverless) {
    this.serverless = serverless
    this.provider = this.serverless.getProvider('aws')
    serverless.httpApiEventsPlugin = this

    this.hooks = {
      initialize: () => {
        if (
          this.serverless.service.provider.name === 'aws' &&
          _.get(this.serverless.service.provider.httpApi, 'useProviderTags')
        ) {
          this.serverless._logDeprecation(
            'AWS_HTTP_API_USE_PROVIDER_TAGS_PROPERTY',
            'Property "provider.httpApi.useProviderTags" is no longer effective as provider tags are applied to Http Api Gateway by default. You can safely remove this property from your configuration.',
          )
        }
      },
      'package:compileEvents': () => {
        this.resolveConfiguration()
        if (!this.config.routes.size) return
        this.cfTemplate =
          this.serverless.service.provider.compiledCloudFormationTemplate
        this.compileApi()
        this.compileLogGroup()
        this.compileStage()
        this.compileAuthorizers()
        this.compileEndpoints()
      },
    }

    this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'httpApi', {
      anyOf: [
        { type: 'string', regexp: methodPathPattern.toString() },
        {
          type: 'object',
          properties: {
            authorizer: {
              anyOf: [
                { type: 'string' },
                {
                  type: 'object',
                  properties: {
                    id: {
                      anyOf: [
                        { type: 'string' },
                        { $ref: '#/definitions/awsCfFunction' },
                      ],
                    },
                    name: { type: 'string' },
                    scopes: { type: 'array', items: { type: 'string' } },
                    type: {
                      type: 'string',
                      enum: ['request', 'jwt', 'aws_iam'],
                    },
                  },
                  anyOf: [
                    { required: ['id'] },
                    { required: ['name'] },
                    { required: ['type'] },
                  ],
                  additionalProperties: false,
                },
              ],
            },
            method: { type: 'string', regexp: methodPattern.toString() },
            path: { type: 'string', regexp: /^(?:\*|\/\S*)$/.toString() },
          },
          required: ['path'],
          additionalProperties: false,
        },
      ],
    })
  }
  getApiIdConfig() {
    return this.config.id || { Ref: this.provider.naming.getHttpApiLogicalId() }
  }
  compileApi() {
    if (this.config.id) return
    const properties = {
      Name: this.provider.naming.getHttpApiName(),
      ProtocolType: 'HTTP',
      DisableExecuteApiEndpoint:
        this.config.disableDefaultEndpoint == null
          ? undefined
          : this.config.disableDefaultEndpoint,
    }
    if (this.serverless.service.provider.tags) {
      const tags = Object.assign({}, this.serverless.service.provider.tags)
      properties.Tags = tags
    }
    const cors = this.config.cors
    if (cors) {
      properties.CorsConfiguration = {
        AllowCredentials: cors.allowCredentials,
        AllowHeaders: Array.from(cors.allowedHeaders),
        AllowMethods: Array.from(cors.allowedMethods),
        AllowOrigins: Array.from(cors.allowedOrigins),
        ExposeHeaders:
          cors.exposedResponseHeaders &&
          Array.from(cors.exposedResponseHeaders),
        MaxAge: cors.maxAge,
      }
    }
    this.cfTemplate.Resources[this.provider.naming.getHttpApiLogicalId()] = {
      Type: 'AWS::ApiGatewayV2::Api',
      Properties: properties,
    }
  }
  compileLogGroup() {
    if (!this.config.accessLogFormat) return

    const resource = {
      Type: 'AWS::Logs::LogGroup',
      Properties: {
        LogGroupName: this.provider.naming.getHttpApiLogGroupName(),
      },
    }

    const logRetentionInDays = this.provider.getLogRetentionInDays()
    if (logRetentionInDays) {
      resource.Properties.RetentionInDays = logRetentionInDays
    }

    const logDataProtectionPolicy = this.provider.getLogDataProtectionPolicy()
    if (logDataProtectionPolicy) {
      resource.Properties.DataProtectionPolicy = logDataProtectionPolicy
    }

    this.cfTemplate.Resources[
      this.provider.naming.getHttpApiLogGroupLogicalId()
    ] = resource
  }
  compileStage() {
    if (this.config.id) return
    const properties = {
      ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() },
      StageName: '$default',
      AutoDeploy: true,
      DefaultRouteSettings: {
        DetailedMetricsEnabled: this.config.metrics,
      },
    }

    if (this.serverless.service.provider.tags) {
      properties.Tags = Object.assign({}, this.serverless.service.provider.tags)
    }

    const resource = (this.cfTemplate.Resources[
      this.provider.naming.getHttpApiStageLogicalId()
    ] = {
      Type: 'AWS::ApiGatewayV2::Stage',
      Properties: properties,
    })
    if (this.config.accessLogFormat) {
      properties.AccessLogSettings = {
        DestinationArn: {
          'Fn::GetAtt': [
            this.provider.naming.getHttpApiLogGroupLogicalId(),
            'Arn',
          ],
        },
        Format: this.config.accessLogFormat,
      }
      resource.DependsOn = this.provider.naming.getHttpApiLogGroupLogicalId()
    }
    this.cfTemplate.Outputs.HttpApiId = {
      Description: 'Id of the HTTP API',
      Value: { Ref: this.provider.naming.getHttpApiLogicalId() },
    }
    this.cfTemplate.Outputs.HttpApiUrl = {
      Description: 'URL of the HTTP API',
      Value: {
        'Fn::Join': [
          '',
          [
            'https://',
            { Ref: this.provider.naming.getHttpApiLogicalId() },
            '.execute-api.',
            { Ref: 'AWS::Region' },
            '.',
            { Ref: 'AWS::URLSuffix' },
          ],
        ],
      },
    }
  }
  compileAuthorizers() {
    for (const authorizer of this.config.authorizers.values()) {
      const authorizerLogicalId =
        this.provider.naming.getHttpApiAuthorizerLogicalId(authorizer.name)

      const authorizerResource = {
        Type: 'AWS::ApiGatewayV2::Authorizer',
        Properties: {
          ApiId: this.getApiIdConfig(),
          Name: authorizer.name,
          IdentitySource: Array.isArray(authorizer.identitySource)
            ? authorizer.identitySource
            : [authorizer.identitySource],
        },
      }

      if (authorizer.type === 'request') {
        // Compile custom (request) authorizer
        authorizerResource.Properties.AuthorizerType = 'REQUEST'
        authorizerResource.Properties.EnableSimpleResponses =
          authorizer.enableSimpleResponses
        authorizerResource.Properties.AuthorizerResultTtlInSeconds =
          authorizer.resultTtlInSeconds
        authorizerResource.Properties.AuthorizerPayloadFormatVersion =
          authorizer.payloadVersion
        authorizerResource.Properties.AuthorizerUri = {
          'Fn::Join': [
            '',
            [
              'arn:',
              { Ref: 'AWS::Partition' },
              ':apigateway:',
              { Ref: 'AWS::Region' },
              ':lambda:path/2015-03-31/functions/',
              authorizer.functionArn ||
                resolveLambdaTarget(
                  authorizer.functionName,
                  authorizer.functionObject,
                ),
              '/invocations',
            ],
          ],
        }
        authorizerResource.DependsOn = _.get(
          _.get(authorizer.functionObject, 'targetAlias'),
          'logicalId',
        )

        // If authorizer is not managed externally, we need to make sure the correct permission is created that
        // allows API Gateway to invoke authorizer function
        if (!authorizer.managedExternally) {
          this.compileAuthorizerLambdaPermission(authorizer)
        }
      } else {
        // Compile JWT Authorizer
        authorizerResource.Properties.AuthorizerType = 'JWT'
        authorizerResource.Properties.JwtConfiguration = {
          Audience: Array.from(authorizer.audience),
          Issuer: authorizer.issuerUrl,
        }
      }

      this.cfTemplate.Resources[authorizerLogicalId] = authorizerResource
    }
  }

  compileAuthorizerLambdaPermission({
    functionName,
    functionArn,
    name,
    functionObject,
  }) {
    const authorizerPermissionLogicalId =
      this.provider.naming.getLambdaAuthorizerHttpApiPermissionLogicalId(name)
    const permissionResource = {
      Type: 'AWS::Lambda::Permission',
      Properties: {
        FunctionName:
          functionArn || resolveLambdaTarget(functionName, functionObject),
        Action: 'lambda:InvokeFunction',
        Principal: 'apigateway.amazonaws.com',
        SourceArn: {
          'Fn::Join': [
            '',
            [
              'arn:',
              { Ref: 'AWS::Partition' },
              ':execute-api:',
              { Ref: 'AWS::Region' },
              ':',
              { Ref: 'AWS::AccountId' },
              ':',
              this.getApiIdConfig(),
              '/*',
            ],
          ],
        },
      },
    }

    if (functionObject && functionObject.targetAlias) {
      permissionResource.DependsOn = functionObject.targetAlias.logicalId
    }
    this.cfTemplate.Resources[authorizerPermissionLogicalId] =
      permissionResource
  }

  compileEndpoints() {
    for (const [
      routeKey,
      { targetData, authorizer, authorizationScopes },
    ] of this.config.routes) {
      this.compileLambdaPermissions(targetData)
      this.compileIntegration(targetData)
      const resource = (this.cfTemplate.Resources[
        this.provider.naming.getHttpApiRouteLogicalId(routeKey)
      ] = {
        Type: 'AWS::ApiGatewayV2::Route',
        Properties: {
          ApiId: this.getApiIdConfig(),
          RouteKey: routeKey === '*' ? '$default' : routeKey,
          Target: {
            'Fn::Join': [
              '/',
              [
                'integrations',
                {
                  Ref: this.provider.naming.getHttpApiIntegrationLogicalId(
                    targetData.functionName,
                  ),
                },
              ],
            ],
          },
        },
        DependsOn: this.provider.naming.getHttpApiIntegrationLogicalId(
          targetData.functionName,
        ),
      })
      if (authorizer) {
        const { id, type } = authorizer

        const authorizationType = (() => {
          if (type === 'request') {
            return 'CUSTOM'
          }

          if (type === 'aws_iam') {
            return 'AWS_IAM'
          }

          return 'JWT'
        })()

        resource.Properties.AuthorizationType = authorizationType

        if (type !== 'aws_iam') {
          Object.assign(resource.Properties, {
            AuthorizerId: id || {
              Ref: this.provider.naming.getHttpApiAuthorizerLogicalId(
                authorizer.name,
              ),
            },
            AuthorizationScopes:
              authorizationScopes && Array.from(authorizationScopes),
          })
        }
      }
    }
  }
}

Object.defineProperties(
  HttpApiEvents.prototype,
  memoizeeMethods({
    resolveConfiguration: d(function () {
      const routes = new Map()
      const providerConfig = this.serverless.service.provider
      const userConfig = providerConfig.httpApi || {}
      this.config = {
        routes,
        id: userConfig.id,
        metrics: userConfig.metrics || false,
        disableDefaultEndpoint: userConfig.disableDefaultEndpoint,
      }
      let cors = null
      let shouldFillCorsMethods = false
      const userCors = userConfig.cors
      if (userCors) {
        if (userConfig.id) {
          throw new ServerlessError(
            'Cannot setup CORS rules for externally configured HTTP API',
            'EXTERNAL_HTTP_API_CORS_CONFIG',
          )
        }
        cors = this.config.cors = {}
        if (userConfig.cors === true) {
          Object.assign(cors, defaultCors)
          shouldFillCorsMethods = true
        } else {
          cors.allowedOrigins = userCors.allowedOrigins
            ? toSet(userCors.allowedOrigins)
            : defaultCors.allowedOrigins
          cors.allowedHeaders = userCors.allowedHeaders
            ? toSet(userCors.allowedHeaders)
            : defaultCors.allowedHeaders
          if (userCors.allowedMethods)
            cors.allowedMethods = toSet(userCors.allowedMethods)
          else shouldFillCorsMethods = true
          if (userCors.allowCredentials) cors.allowCredentials = true
          if (userCors.exposedResponseHeaders) {
            cors.exposedResponseHeaders = toSet(userCors.exposedResponseHeaders)
          }
          cors.maxAge = userCors.maxAge
        }
        if (shouldFillCorsMethods) cors.allowedMethods = new Set(['OPTIONS'])
      }

      const userAuthorizers = userConfig.authorizers
      const authorizers = (this.config.authorizers = new Map())
      if (userAuthorizers) {
        if (userConfig.id) {
          throw new ServerlessError(
            'Cannot setup authorizers for externally configured HTTP API',
            'EXTERNAL_HTTP_API_AUTHORIZERS_CONFIG',
          )
        }
        for (const [name, authorizerConfig] of Object.entries(
          userAuthorizers,
        )) {
          let authorizerFunctionObject

          if (authorizerConfig.type === 'request') {
            if (
              !authorizerConfig.functionArn &&
              !authorizerConfig.functionName
            ) {
              throw new ServerlessError(
                `Either "functionArn" or "functionName" property needs to be set on authorizer "${name}"`,
                'HTTP_API_CUSTOM_AUTHORIZER_NEITHER_FUNCTION_ARN_NOR_FUNCTION_NAME_DEFINED',
              )
            }

            if (authorizerConfig.functionArn && authorizerConfig.functionName) {
              throw new ServerlessError(
                `Either "functionArn" or "functionName" (not both) property needs to be set on authorizer "${name}"`,
                'HTTP_API_CUSTOM_AUTHORIZER_BOTH_FUNCTION_ARN_AND_FUNCTION_NAME_DEFINED',
              )
            }

            if (authorizerConfig.functionName) {
              try {
                authorizerFunctionObject = this.serverless.service.getFunction(
                  authorizerConfig.functionName,
                )
              } catch {
                throw new ServerlessError(
                  `Function "${authorizerConfig.functionName}" for HTTP API authorizer "${name}" not found in service.`,
                  'HTTP_API_CUSTOM_AUTHORIZER_FUNCTION_NOT_FOUND_IN_SERVICE',
                )
              }
            }

            if (
              authorizerConfig.resultTtlInSeconds &&
              !authorizerConfig.identitySource
            ) {
              throw new ServerlessError(
                `Property "identitySource" has to be set on authorizer "${name}" when "resultTtlInSeconds" is set to non-zero value.`,
                'HTTP_API_CUSTOM_AUTHORIZER_IDENTITY_SOURCE_MISSING_WHEN_CACHING_ENABLED',
              )
            }
          }

          authorizers.set(name, {
            name: authorizerConfig.name || name,
            identitySource: authorizerConfig.identitySource || [],
            issuerUrl: authorizerConfig.issuerUrl,
            audience: toSet(authorizerConfig.audience),
            type: authorizerConfig.type,
            functionName: authorizerConfig.functionName,
            functionArn: authorizerConfig.functionArn,
            managedExternally: authorizerConfig.managedExternally,
            resultTtlInSeconds: authorizerConfig.resultTtlInSeconds,
            enableSimpleResponses: authorizerConfig.enableSimpleResponses,
            payloadVersion: authorizerConfig.payloadVersion || '2.0',
            functionObject: authorizerFunctionObject,
          })
        }
      }

      const userLogsConfig = providerConfig.logs && providerConfig.logs.httpApi
      if (userLogsConfig) {
        if (userConfig.id) {
          throw new ServerlessError(
            'Cannot setup access logs for externally configured HTTP API',
            'EXTERNAL_HTTP_API_LOGS_CONFIG',
          )
        }
        this.config.accessLogFormat =
          userLogsConfig.format ||
          `${JSON.stringify({
            requestId: '$context.requestId',
            ip: '$context.identity.sourceIp',
            requestTime: '$context.requestTime',
            httpMethod: '$context.httpMethod',
            routeKey: '$context.routeKey',
            status: '$context.status',
            protocol: '$context.protocol',
            responseLength: '$context.responseLength',
          })}`
      }

      for (const [functionName, functionData] of Object.entries(
        this.serverless.service.functions,
      )) {
        const routeTargetData = {
          functionName,
          functionAlias: functionData.targetAlias,
          functionLogicalId:
            this.provider.naming.getLambdaLogicalId(functionName),
        }
        let hasHttpApiEvents = false
        for (const event of functionData.events) {
          if (!event.httpApi) continue
          hasHttpApiEvents = true
          let method
          let path
          let authorizer
          if (_.isObject(event.httpApi)) {
            ;({ method, path, authorizer } = event.httpApi)
          } else {
            const methodPath = String(event.httpApi)
            if (methodPath === '*') {
              path = '*'
            } else {
              ;[, method, path] = methodPath.match(methodPathPattern)
            }
          }
          path = String(path)
          let routeKey
          if (path === '*') {
            if (method && method !== '*') {
              throw new ServerlessError(
                `Invalid "path" property in function ${functionName} for httpApi event in serverless.yml`,
                'INVALID_HTTP_API_PATH',
              )
            }
            routeKey = '*'
            event.resolvedMethod = 'ANY'
          } else {
            if (!method) {
              throw new ServerlessError(
                `Missing "method" property in function ${functionName} for httpApi event in serverless.yml`,
                'MISSING_HTTP_API_METHOD',
              )
            }
            method = String(method).toUpperCase()
            if (method === '*') {
              method = 'ANY'
            } else if (!allowedMethods.has(method)) {
              throw new ServerlessError(
                `Invalid "method" property in function ${functionName} for httpApi event in serverless.yml`,
                'INVALID_HTTP_API_METHOD',
              )
            }
            event.resolvedMethod = method
            event.resolvedPath = path
            routeKey = `${method} ${path}`

            if (routes.has(routeKey)) {
              throw new ServerlessError(
                `Duplicate route '${routeKey}' configuration in function ${functionName} for httpApi event in serverless.yml`,
                'DUPLICATE_HTTP_API_ROUTE',
              )
            }
          }
          const routeConfig = { targetData: routeTargetData }
          if (authorizer) {
            const { name, scopes, id, type } = (() => {
              if (_.isObject(authorizer)) return authorizer
              return { name: authorizer }
            })()

            if (type !== 'aws_iam' && !id && !name) {
              throw new ServerlessError(
                `When configuring an authorizer with type: "${
                  type || 'jwt'
                }", property "id" or "name" has to be specified.`,
                'HTTP_API_AUTHORIZER_MISSING_ID_OR_NAME',
              )
            }

            if (type === 'aws_iam' && (name || id || scopes)) {
              throw new ServerlessError(
                'When configuring authorizer with type: "aws_iam", all other properties are not supported.',
                'HTTP_API_AUTHORIZER_AWS_IAM_UNEXPECTED_PROPERTIES',
              )
            }

            if (id) {
              if (!userConfig.id) {
                throw new ServerlessError(
                  `Event references external authorizer '${id}', but httpApi is part of the current stack.`,
                  'EXTERNAL_HTTP_API_AUTHORIZER_WITHOUT_EXTERNAL_HTTP_API',
                )
              }
              routeConfig.authorizer = { id, type }
            } else if (type === 'aws_iam') {
              routeConfig.authorizer = authorizer
            } else if (!authorizers.has(name)) {
              throw new ServerlessError(
                `Event references not configured authorizer '${name}'`,
                'UNRECOGNIZED_HTTP_API_AUTHORIZER',
              )
            } else {
              routeConfig.authorizer = authorizers.get(name)
            }
            if (scopes) routeConfig.authorizationScopes = toSet(scopes)
          }
          routes.set(routeKey, routeConfig)
          if (shouldFillCorsMethods) {
            if (event.resolvedMethod === 'ANY') {
              for (const allowedMethod of allowedMethods) {
                if (allowedMethod === 'ANY') {
                  continue
                }
                cors.allowedMethods.add(allowedMethod)
              }
            } else {
              cors.allowedMethods.add(event.resolvedMethod)
            }
          }
        }
        if (!hasHttpApiEvents) continue
        const functionTimeout =
          Number(functionData.timeout) ||
          Number(this.serverless.service.provider.timeout) ||
          6

        if (functionTimeout > 30) {
          log.warning(
            `Function (${functionName}) timeout setting (${functionTimeout}) is greater than ` +
              'maximum allowed timeout for HTTP API endpoint (30s). ' +
              'This may introduce a situation where endpoint times out ' +
              'for a successful lambda invocation.',
          )
        } else if (functionTimeout === 30) {
          log.warning(
            `Function (${functionName}) timeout setting (${functionTimeout}) may not provide ` +
              'enough room to process an HTTP API request (of which timeout is limited to 30s). ' +
              'This may introduce a situation where endpoint times out ' +
              'for a successful lambda invocation.',
          )
        }
      }
    }),
    compileIntegration: d(function (routeTargetData) {
      const functionConfig = this.serverless.service.getFunction(
        routeTargetData.functionName,
      )
      const funcHttpApi = functionConfig.httpApi || {}
      const providerConfig = this.serverless.service.provider
      const providerHttpApi = providerConfig.httpApi || {}

      const properties = {
        ApiId: this.getApiIdConfig(),
        IntegrationType: 'AWS_PROXY',
        IntegrationUri: resolveTargetConfig(routeTargetData),
        PayloadFormatVersion:
          funcHttpApi.payload || providerHttpApi.payload || '2.0',
        TimeoutInMillis: 30000,
      }
      this.cfTemplate.Resources[
        this.provider.naming.getHttpApiIntegrationLogicalId(
          routeTargetData.functionName,
        )
      ] = {
        Type: 'AWS::ApiGatewayV2::Integration',
        DependsOn: _.get(routeTargetData.functionAlias, 'logicalId'),
        Properties: properties,
      }
    }),
    compileLambdaPermissions: d(function (routeTargetData) {
      this.cfTemplate.Resources[
        this.provider.naming.getLambdaHttpApiPermissionLogicalId(
          routeTargetData.functionName,
        )
      ] = {
        Type: 'AWS::Lambda::Permission',
        Properties: {
          FunctionName: resolveTargetConfig(routeTargetData),
          Action: 'lambda:InvokeFunction',
          Principal: 'apigateway.amazonaws.com',
          SourceArn: {
            'Fn::Join': [
              '',
              [
                'arn:',
                { Ref: 'AWS::Partition' },
                ':execute-api:',
                { Ref: 'AWS::Region' },
                ':',
                { Ref: 'AWS::AccountId' },
                ':',
                this.getApiIdConfig(),
                '/*',
              ],
            ],
          },
        },
        DependsOn: routeTargetData.functionAlias
          ? routeTargetData.functionAlias.logicalId
          : undefined,
      }
    }),
  }),
)

export default HttpApiEvents
